Multi-stage Build로 이미지 크기 줄이기
Multi-stage Build로 이미지 크기 줄이기
Multi-stage Build의 핵심은 단순히 MB를 줄이는 것이 아니라 build 환경과 실행 환경의 신뢰 경계를 나누는 것이다. compiler, source, test tool, package manager를 builder stage에 두고 검증된 artifact와 필수 runtime dependency만 final stage로 옮긴다. 이때 base image의 libc와 architecture, dynamic library, CA certificate, time zone data, non-root 권한까지 확인해야 한다. 작은 이미지는 전송과 공격 표면을 줄이지만, shell이 없는 환경의 디버깅과 취약점 탐지 결과를 함께 운영할 방법도 필요하다.
목차
- #하나의 이미지에 빌드와 실행을 모두 넣었을 때
- #Stage는 독립된 파일시스템이다
- #최종 이미지에 무엇을 복사할지 먼저 정하기
- #Node.js 서비스를 Stage로 분리하기
- #Compiled Binary는 동적 의존성을 확인하기
- #Alpine과 Slim을 크기만으로 고르지 않기
- #Non-root 실행을 Final Stage에서 보장하기
- #Base Image와 Dependency를 재현 가능하게 고정하기
- #테스트 Stage와 Production Stage 나누기
- #Build Secret과 Source가 Final Image에 없는지 확인하기
- #작은 이미지와 낮은 위험은 같은 말이 아니다
- #Shell 없는 이미지의 운영과 디버깅
- #이미지 크기와 내용 측정하기
- #실패 조건을 포함한 검증
- #구현 체크리스트
- #마무리
- #관련 노트
- #참고 자료
하나의 이미지에 빌드와 실행을 모두 넣었을 때
TypeScript 서비스를 한 stage에서 build한다고 하자.
FROM node:22-bookworm
WORKDIR /workspace
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm test
RUN npm run build
CMD ["node", "dist/server.js"]
실행은 되지만 production container에는 실행과 무관한 것들이 남는다.
- TypeScript compiler와 test framework
- source map을 포함한 원본 source
- development dependency
- package manager cache
- test fixture와 coverage 결과
- build에만 필요한 shell과 OS package
이것은 단지 image 크기 문제만은 아니다. production filesystem에 source와 build tool이 있으면 노출할 정보와 실행 가능한 도구가 늘고, vulnerability scanner가 평가할 package도 많아진다. 장애 조사 때 어떤 파일이 runtime에 필요한지 구분하기도 어렵다.
“builder image를 작게 만들기”보다 “final image의 책임을 실행에 한정하기”가 먼저다.
Stage는 독립된 파일시스템이다
Dockerfile에서 새로운 FROM이 나오면 별도 stage가 시작된다.
FROM node:22-bookworm AS build
WORKDIR /workspace
COPY . .
RUN npm ci && npm run build
FROM node:22-bookworm-slim AS runtime
WORKDIR /workspace
COPY --from=build /workspace/dist ./dist
CMD ["node", "dist/server.js"]
두 번째 stage는 첫 번째 stage의 전체 filesystem을 상속하지 않는다. COPY --from=build로 고른 경로만 가져온다.
flowchart LR
A[Source and lock file] --> B[Build stage]
B --> C[Test]
C --> D[dist artifact]
D --> E[Runtime stage]
F[Runtime dependencies] --> E
E --> G[Production image]stage 이름을 0, 1 같은 index 대신 지정하면 순서를 바꿔도 참조가 유지된다.
COPY --from=build /workspace/dist ./dist
BuildKit은 선택한 target이 의존하지 않는 stage를 생략할 수 있다. 하지만 stage가 많다는 이유만으로 자동 최적화되는 것은 아니다. stage dependency를 읽을 수 있게 이름과 책임을 명확히 둔다.
최종 이미지에 무엇을 복사할지 먼저 정하기
Multi-stage Dockerfile을 쓰기 전에 runtime manifest를 작성해 보는 편이 좋다.
| 분류 | 예 | Final image 포함 |
|---|---|---|
| 실행 artifact | dist/server.js, binary |
예 |
| runtime dependency | production node_modules, shared library |
예 |
| 설정 schema | validation에 필요한 JSON | 필요할 때 |
| migration | 배포 과정에서 같은 image가 실행한다면 | 정책에 따라 |
| source | TypeScript 원본 | 보통 아니오 |
| test | fixture, coverage, test runner | 아니오 |
| compiler | TypeScript, GCC, JDK | 아니오 |
| credential | .npmrc, signing key |
절대 아니오 |
COPY --from=build /workspace /workspace처럼 builder 전체를 옮기면 stage를 나눈 의미가 약해진다.
# 경계가 너무 넓다.
COPY --from=build /workspace /workspace
artifact 경로를 고정하고 필요한 것을 명시한다.
COPY --from=build /workspace/dist ./dist
COPY --from=production-deps /workspace/node_modules ./node_modules
COPY package.json ./
이 목록 자체가 runtime dependency 문서가 된다.
Node.js 서비스를 Stage로 분리하기
다음은 가상의 TypeScript API를 위한 구조다.
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS base
WORKDIR /workspace
ENV CI=true
FROM base AS dependencies
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
FROM dependencies AS test
COPY tsconfig.json ./
COPY src ./src
COPY test ./test
RUN npm test
FROM dependencies AS build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM base AS production-deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev \
&& npm cache clean --force
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /workspace
COPY --from=production-deps --chown=node:node \
/workspace/node_modules ./node_modules
COPY --from=build --chown=node:node \
/workspace/dist ./dist
COPY --chown=node:node package.json ./
USER node
EXPOSE 8080
CMD ["node", "dist/server.js"]
이 예제에는 의도적인 선택이 있다.
- development dependency가 있는
dependencies는 build/test에만 쓴다. - production dependency는 별도 stage에서 lock file 기준으로 설치한다.
- final stage에는
dist, productionnode_modules, package metadata만 복사한다. - 파일 ownership을 복사 시점에 맞춰 별도
chownlayer를 만들지 않는다. - process는 image가 제공하는 non-root user로 실행한다.
npm prune --omit=dev로 하나의 설치 결과를 줄이는 방법도 있다. 속도는 나을 수 있지만 install script와 native addon 결과가 production-only clean install과 같은지 확인해야 한다.
framework가 runtime에 view template, static file, migration, generated schema를 필요로 한다면 명시적으로 추가해야 한다.
Compiled Binary는 동적 의존성을 확인하기
Go나 Rust binary 하나만 scratch로 복사하면 매우 작은 image를 만들 수 있다.
# syntax=docker/dockerfile:1
FROM golang:1.26-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/sample-api ./cmd/api
FROM scratch AS runtime
COPY --from=build /out/sample-api /sample-api
ENTRYPOINT ["/sample-api"]
하지만 모든 binary가 정적으로 연결되는 것은 아니다. CGO, OpenSSL, image processing library처럼 shared library가 필요하면 builder에는 있던 .so가 final stage에 없어 기동에 실패한다.
error while loading shared libraries:
libexample.so.1: cannot open shared object file
확인할 항목은 다음과 같다.
- binary architecture가 target platform과 같은가
- dynamic linker와 shared library가 존재하는가
- DNS resolution 방식이 final base에서 동작하는가
- outbound TLS에 CA certificate가 있는가
- local time 변환에 time zone database가 필요한가
- user lookup에 필요한
/etc/passwd정보가 있는가
scratch가 맞지 않으면 필요한 runtime component가 포함된 slim 또는 distroless 계열이 더 안전할 수 있다.
Alpine과 Slim을 크기만으로 고르지 않기
Alpine 기반 image는 작지만 musl libc를 사용한다. Debian/Ubuntu 계열 slim은 보통 glibc 환경이다. 이 차이는 native module과 prebuilt binary 호환성에 영향을 준다.
| 기준 | Alpine 계열 | Debian slim 계열 |
|---|---|---|
| 기본 크기 | 대체로 작음 | 상대적으로 큼 |
| libc | musl | glibc |
| native binary 호환 | 별도 build가 필요할 수 있음 | glibc 배포 artifact와 맞는 경우가 많음 |
| package ecosystem | apk |
apt |
| 익숙한 진단 도구 | 제한적일 수 있음 | 선택 폭이 비교적 큼 |
builder는 glibc인데 runtime을 Alpine으로 바꾸면 native addon이 깨질 수 있다.
# 위험할 수 있는 조합
FROM node:22-bookworm AS build
RUN npm ci
FROM node:22-alpine
COPY --from=build /workspace/node_modules ./node_modules
순수 JavaScript dependency만 있다는 근거가 없다면 같은 OS family와 runtime version을 맞추는 편이 예측 가능하다. Alpine을 선택한다면 builder도 같은 target 환경으로 두고 실제 native dependency를 test한다.
image가 40MB 줄어도 production crash나 DNS·TLS 차이를 만들면 최적화가 아니다.
Non-root 실행을 Final Stage에서 보장하기
build stage는 package 설치 때문에 root로 실행될 수 있다. 그 사실이 final stage의 runtime 권한까지 결정할 필요는 없다.
FROM node:22-bookworm-slim AS runtime
WORKDIR /workspace
COPY --from=build --chown=node:node /workspace/dist ./dist
USER node
CMD ["node", "dist/server.js"]
application이 runtime에 파일을 써야 한다면 디렉터리를 먼저 준비한다.
RUN mkdir -p /workspace/tmp \
&& chown node:node /workspace/tmp
USER node
더 좋은 방향은 writable path를 좁히고 upload나 durable data를 외부 volume/object storage로 보내는 것이다. root filesystem read-only 옵션과 함께 시험할 수 있다.
docker run --rm \
--read-only \
--tmpfs /workspace/tmp:rw,noexec,nosuid,size=64m \
sample-api:test
non-root 전환 후에는 1024 미만의 privileged port, bind mount ownership, certificate path, temporary directory를 확인한다.
Base Image와 Dependency를 재현 가능하게 고정하기
node:latest는 시간이 지나면 다른 runtime과 OS를 가리킨다. 최소한 major와 OS family를 명시하고, 강한 재현성이 필요하면 digest로 고정한다.
FROM node:22-bookworm-slim@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef AS runtime
가상 digest이므로 실제 사용하면 안 된다. 고정 후에도 update 자동화가 필요하다.
builder와 runtime의 runtime version이 다르면 build output이 기대하는 기능이 없을 수 있다.
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-bookworm-slim AS build
# ...
FROM node:${NODE_VERSION}-bookworm-slim AS runtime
ARG로 한 곳에서 맞추는 것은 편리하지만 tag 해석 시점은 여전히 변할 수 있다. CI가 실제 해석된 digest, package lock checksum, artifact checksum을 기록하면 배포 조사에 도움이 된다.
테스트 Stage와 Production Stage 나누기
test가 성공해야 production image를 만들 수 있게 dependency graph를 구성해야 한다. 단순히 test stage를 Dockerfile에 써 두는 것만으로 final target이 그것을 실행한다는 보장은 없다.
FROM dependencies AS test
COPY src ./src
COPY test ./test
RUN npm test
FROM dependencies AS build
COPY src ./src
RUN npm run build
runtime이 build에만 의존하면 BuildKit은 test를 건너뛸 수 있다. CI에서 target을 명시적으로 실행한다.
docker buildx build --target test .
docker buildx build --target runtime --tag sample-api:candidate .
더 강한 연결이 필요하면 test가 검증한 artifact를 다음 stage가 받도록 구조를 바꾼다. 다만 test 실행 결과를 증명하는 빈 파일을 복사하는 기교보다는 CI pipeline의 required job과 artifact digest를 명확히 관리하는 편이 읽기 쉽다.
개발용 stage도 별도로 둘 수 있다.
FROM dependencies AS development
COPY . .
CMD ["npm", "run", "dev"]
production target에 development stage의 source나 port 설정이 섞이지 않게 한다.
Build Secret과 Source가 Final Image에 없는지 확인하기
Multi-stage Build는 builder layer를 final image manifest에 넣지 않지만 이것만으로 secret 사용이 안전해지는 것은 아니다. remote builder cache와 CI log, registry cache export에는 builder 관련 정보가 남을 수 있다.
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
secret mount를 사용하고, source repository에도 credential을 두지 않는다. final image는 별도로 검사한다.
docker history --no-trunc sample-api:candidate
docker image save sample-api:candidate --output sample-api.tar
archive를 무작정 production 환경에서 풀기보다 격리된 CI scanner로 다음을 찾는다.
.env,.npmrc, SSH key- source와 test fixture
- package manager cache
- compiler와 build tool
- credential처럼 보이는 build argument
scanner가 못 찾았다고 secret이 없다고 단정하지 않는다. 애초에 build context와 명령에 secret이 들어오지 않는 구조가 우선이다.
작은 이미지와 낮은 위험은 같은 말이 아니다
package 수가 줄면 일반적으로 알려진 취약점 후보와 공격 도구가 줄어든다. 그러나 image 크기와 위험이 비례하는 것은 아니다.
Image A: 40 MB, 인터넷에 노출된 치명적 runtime 취약점 1개
Image B: 120 MB, 사용되지 않는 low severity package 여러 개
크기만으로 A가 안전하다고 할 수 없다. 실제 평가에는 다음 맥락이 필요하다.
- 취약한 package가 runtime 경로에서 reachable한가
- process 권한과 Linux capability는 무엇인가
- filesystem과 network policy가 얼마나 제한되는가
- base와 application dependency update 속도는 어떤가
- image provenance와 signature를 검증하는가
Multi-stage는 공격 표면을 줄이는 한 수단이다. 취약점 scan, non-root, read-only filesystem, 최소 capability, 배포 정책과 함께 사용한다.
Shell 없는 이미지의 운영과 디버깅
distroless나 scratch에는 shell, curl, ps가 없을 수 있다. 이것은 공격자가 악용할 도구를 줄이고 runtime 변형을 막는 장점이 있지만, 기존의 docker exec -it ... sh 장애 대응은 통하지 않는다.
운영 방식을 바꿔야 한다.
- application이 구조화된 log와 metrics를 제공한다.
- health endpoint가 dependency별 상태를 구분한다.
- core dump와 trace 수집 경로를 미리 설계한다.
- 동일 digest를 기반으로 한 debug variant를 별도 관리한다.
- ephemeral debug container나 platform debug 기능을 쓴다.
FROM runtime-base AS production
COPY --from=build /out/sample-api /sample-api
FROM debug-base AS debug
COPY --from=build /out/sample-api /sample-api
RUN install-debug-tools
install-debug-tools는 개념을 나타내는 가상 명령이다. debug image를 production에 상시 배포하지 않고, 접근 권한과 보존 기간을 제한한다.
이미지 크기와 내용 측정하기
최적화 전후에는 compressed registry size, local uncompressed size, layer 구성, startup 영향 등을 따로 본다.
docker image ls sample-api
docker history sample-api:candidate
docker image inspect sample-api:candidate
비교 표는 실제 CI 결과로 채우는 것이 좋다.
| 지표 | Single stage | Multi-stage |
|---|---|---|
| registry 전송 크기 | 측정 필요 | 측정 필요 |
| package 수 | 측정 필요 | 측정 필요 |
| critical/high finding | 측정 필요 | 측정 필요 |
| cold pull 시간 | 측정 필요 | 측정 필요 |
| build 시간 | 측정 필요 | 측정 필요 |
큰 layer가 무엇인지 확인하고, 단순히 stage 개수만 늘렸는데 final image가 같다면 실제로 불필요한 파일을 제외했는지 다시 본다.
실패 조건을 포함한 검증
구현 체크리스트
마무리
Multi-stage Build는 한 Dockerfile 안에서 build와 runtime의 filesystem을 분리한다. builder에는 source, compiler, development dependency를 둘 수 있지만 final image에는 선택한 artifact와 runtime dependency만 전달한다.
이 경계가 명확하면 image 전송량과 package 수가 줄고, production에서 사용할 수 있는 도구와 노출되는 source도 줄어든다. 하지만 가장 작은 base를 고르는 것만으로 끝나지 않는다. architecture, libc, shared library, CA certificate, time zone data, 파일 권한이 실제 application과 맞아야 한다.
또한 shell과 진단 도구를 제거했다면 관측 가능성과 debug 절차를 다른 방식으로 제공해야 한다. 크기 감소 수치만큼 clean build, non-root 기동, native dependency, 보안 scan, 장애 대응을 함께 검증해야 한다.
좋은 final image는 단순히 작은 image가 아니다. 무엇이 들어 있고 왜 필요한지 설명할 수 있으며, build 환경의 불필요한 권한과 자료가 production 경계를 넘지 않는 image다.